#!/usr/bin/env python3
"""
Telepresence CLI tool: local development environment for a remote
Kubernetes cluster.
"""

import argparse
import atexit
from copy import deepcopy
import json
import os
import os.path
import signal
import socket
import ssl
import sys
from functools import wraps
from shutil import rmtree, copy, which
from subprocess import (
    check_output, Popen, CalledProcessError, run, TimeoutExpired, STDOUT,
    DEVNULL
)
from tempfile import mkdtemp, NamedTemporaryFile
from time import sleep, time
from traceback import print_exc
from urllib.error import HTTPError
from urllib.request import urlopen
from uuid import uuid4
import webbrowser
from io import StringIO
from urllib.parse import quote_plus
unicode = str

if sys.version_info[:2] < (3, 5):
    raise SystemExit("Telepresence requires Python 3.5 or later.")

# Don't modify next line without modifying corresponding line in
# .bumpversion.cfg:
__version__ = "0.51"
# Test runs can override version so we use specific custom Docker images:
if os.environ.get("TELEPRESENCE_VERSION") is not None:
    __version__ = os.environ["TELEPRESENCE_VERSION"]
REGISTRY = os.environ.get("TELEPRESENCE_REGISTRY", "datawire")
TELEPRESENCE_IMAGE = "{}/telepresence-k8s:{}".format(REGISTRY, __version__)


def random_name():
    """Return a random name for a container."""
    return "telepresence-{}-{}".format(time(), os.getpid()).replace(".", "-")


def find_free_port():
    """
    Find a port that isn't in use.

    XXX race condition-prone.
    """
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    try:
        s.bind(("", 0))
        return s.getsockname()[1]
    finally:
        s.close()


def read_logs(logfile):
    """Read logfile, return string."""
    logs = "Not available"
    if logfile != "-" and os.path.exists(logfile):
        try:
            with open(logfile, "r") as logfile:
                logs = logfile.read()
        except Exception as e:
            logs += ", error ({})".format(e)
    return logs


class handle_unexpected_errors(object):
    """Decorator that catches unexpected errors."""

    def __init__(self, logfile):
        self.logfile = logfile

    def __call__(self, f):
        def safe_output(args):
            try:
                return unicode(check_output(args), "utf-8").strip()
            except Exception as e:
                return "(error: {})".format(e)

        @wraps(f)
        def call_f(*args, **kwargs):
            try:
                return f(*args, **kwargs)
            except SystemExit:
                raise
            except KeyboardInterrupt:
                raise SystemExit(0)
            except Exception as e:
                logs = read_logs(self.logfile)
                errorf = StringIO()
                print_exc(file=errorf)
                error = errorf.getvalue()
                print(
                    "\nLooks like there's a bug in our code. Sorry about that!"
                    "\n\n"
                    "Here's the traceback:\n\n" + error + "\n"
                )
                if self.logfile != "-":
                    print(
                        "And here are the last few lines of the logfile "
                        "(see {} for the complete logs):\n\n".
                        format(self.logfile) +
                        "\n".join(logs.splitlines()[-20:]) + "\n"
                    )

                if input(
                    "Would you like to file an issue in our issue tracker?"
                    " We'd really appreciate the help improving our "
                    "product. [Y/n]: ",
                ).lower() in ("y", ""):
                    url = (
                        "https://github.com/datawire/telepresence/issues/" +
                        "new?body="
                    )
                    body = quote_plus(
                        # Overly long URLs won't work:
                        BUG_REPORT_TEMPLATE.format(
                            sys.argv, __version__, sys.version,
                            safe_output([
                                "kubectl", "version", "--short", "--client"
                            ]),
                            safe_output(["oc", "version"]),
                            safe_output(["uname", "-a"]), error, logs[-1000:]
                        )[:4000]
                    )
                    webbrowser.open_new(url + body)
                else:
                    raise SystemExit(1)

        return call_f


class Runner(object):
    """Context for running subprocesses."""

    def __init__(self, logfile, kubectl_cmd, verbose):
        """
        :param logfile: file-like object to write logs to.
        :param kubectl_cmd: Command to run for kubectl, either "kubectl" or
            "oc" (for OpenShift Origin).
        :param verbose: Whether subcommand should run in verbose mode.
        """
        self.logfile = logfile
        self.kubectl_cmd = kubectl_cmd
        self.verbose = verbose

    @classmethod
    def open(cls, logfile_path, kubectl_cmd, verbose):
        """
        :return: File-like object for the given logfile path.
        """
        if logfile_path == "-":
            return cls(sys.stdout, kubectl_cmd, verbose)
        else:
            # Wipe existing logfile, and use line buffering so data gets
            # written out immediately.
            if os.path.exists(logfile_path):
                os.remove(logfile_path)
            return cls(
                open(logfile_path, "a", buffering=1), kubectl_cmd, verbose
            )

    def write(self, message):
        """Write a message to the log."""
        self.logfile.write(message)
        self.logfile.flush()

    def check_call(self, *args, **kwargs):
        """Run a subprocess, make sure it exited with 0."""
        self.write("Running: {}... ".format(args))
        if "input" not in kwargs and "stdin" not in kwargs:
            kwargs["stdin"] = DEVNULL
        run(
            *args,
            stdout=self.logfile,
            stderr=self.logfile,
            check=True,
            **kwargs
        )
        self.write(" ran!\n")

    def get_output(self, *args, stderr=None, **kwargs):
        """Return (stripped) command result as unicode string."""
        if stderr is None:
            stderr = self.logfile
        self.write("Running: {}...".format(args))
        result = unicode(
            check_output(*args, stdin=DEVNULL, stderr=stderr,
                         **kwargs).strip(), "utf-8"
        )
        self.write(" ran!\n")
        return result

    def popen(self, *args, stdin=DEVNULL, **kwargs):
        """Return Popen object."""
        self.write("Running: {}...".format(args))
        return Popen(
            *args,
            stdin=stdin,
            stderr=self.logfile,
            stdout=self.logfile,
            **kwargs
        )

    def kubectl(self, context, namespace, args):
        """Return command-line for running kubectl."""
        result = [self.kubectl_cmd]
        if self.verbose:
            result.append("--v=4")
        result.extend(["--context", context])
        result.extend(["--namespace", namespace])
        result += args
        return result

    def get_kubectl(self, context, namespace, args, stderr=None):
        """Return output of running kubectl."""
        return self.get_output(
            self.kubectl(context, namespace, args), stderr=stderr
        )

    def check_kubectl(self, context, namespace, kubectl_args, **kwargs):
        """Check exit code of running kubectl."""
        self.check_call(
            self.kubectl(context, namespace, kubectl_args), **kwargs
        )


@handle_unexpected_errors("-")
def parse_args():
    """Create a new ArgumentParser and parse sys.argv."""
    parser = argparse.ArgumentParser(
        formatter_class=argparse.RawDescriptionHelpFormatter,
        allow_abbrev=False,  # can make adding changes not backwards compatible
        description=(
            "Telepresence: local development proxied to a remote Kubernetes "
            "cluster.\n\n"
            "Documentation: http://telepresence.io\n"
            "Real-time help: https://gitter.im/datawire/telepresence\n"
            "Issue tracker: https://github.com/datawire/telepresence/issues\n"
        )
    )
    parser.add_argument('--version', action='version', version=__version__)
    parser.add_argument(
        "--verbose",
        action='store_true',
        help=("Enables verbose logging for troubleshooting.")
    )
    parser.add_argument(
        "--logfile",
        default="./telepresence.log",
        help=(
            "The path to write logs to. '-' means stdout, "
            "default is './telepresence.log'."
        )
    )
    parser.add_argument(
        "--method",
        "-m",
        required=True,
        choices=["inject-tcp", "vpn-tcp"],
        help=(
            "inject-tcp: inject process-specific shared "
            "library that proxies TCP to the remote cluster.\n"
            "vpn-tcp: all local processes can route TCP "
            "traffic to the remote cluster.\n\nFor more details see "
            "http://www.telepresence.io/reference/methods.html"
        )
    )
    group_deployment = parser.add_mutually_exclusive_group(required=True)
    group_deployment.add_argument(
        '--new-deployment',
        "-n",
        dest="new_deployment",
        help=(
            "Create a new Deployment in Kubernetes where the "
            "datawire/telepresence-k8s image will run. It will be deleted "
            "on exit."
        )
    )
    group_deployment.add_argument(
        "--swap-deployment",
        "-s",
        dest="swap_deployment",
        metavar="DEPLOYMENT[:CONTAINER]",
        help=(
            "Swap out an existing deployment with the Telepresence proxy, "
            "swap back on exit. If there are multiple containers in the pod "
            "then add the optional container name to indicate which container"
            " to use."
        )
    )
    group_deployment.add_argument(
        "--deployment",
        "-d",
        help=(
            "The name of an existing Kubernetes Deployment where the " +
            "datawire/telepresence-k8s image is already running."
        )
    )
    parser.add_argument(
        "--context",
        default=None,
        help=(
            "The Kubernetes context to use. Defaults to current kubectl"
            " context."
        )
    )
    parser.add_argument(
        "--namespace",
        default=None,
        help=(
            "The Kubernetes namespace to use. Defaults to kubectl's default"
            " for the current context, which is usually 'default'."
        )
    )
    parser.add_argument(
        "--expose",
        type=int,
        action='append',
        default=[],
        help=(
            "Local port number that will be exposed to Kubernetes. Should"
            " match port exposed in the existing Deployment if using "
            "--deployment or --swap-deployment."
        )
    )
    parser.add_argument(
        "--also-proxy",
        metavar="CLOUD_HOSTNAME",
        dest="also_proxy",
        action='append',
        default=[],
        help=(
            "If you are using --method=vpn-tcp, use this to add additional "
            "remote IPs or hostnames to proxy. Kubernetes service and pods "
            "are proxied automatically, so you only need to list cloud "
            "resources, e.g. the hostname of a AWS RDS. "
            "When using --method=inject-tcp "
            "this option is unnecessary as all outgoing communication in "
            "the run subprocess will be proxied."
        )
    )
    group = parser.add_mutually_exclusive_group()
    group.add_argument(
        "--run-shell",
        dest="runshell",
        action="store_true",
        help="Run a local shell that will be proxied to/from Kubernetes.",
    )
    group.add_argument(
        "--run",
        metavar=("COMMAND", "ARG"),
        dest="run",
        nargs=argparse.REMAINDER,
        help=(
            "Run the specified command arguments, e.g. "
            "'--run python myapp.py'."
        )
    )
    return parser.parse_args()


class RemoteInfo(object):
    """
    Information about the remote setup.

    :ivar namespace str: The Kubernetes namespace.
    :ivar context str: The Kubernetes context.
    :ivar deployment_name str: The name of the Deployment object.
    :ivar pod_name str: The name of the pod created by the Deployment.
    :ivar deployment_config dict: The decoded k8s object (i.e. JSON/YAML).
    :ivar container_config dict: The container within the Deployment JSON.
    :ivar container_name str: The name of the container.
    """

    def __init__(
        self,
        runner,
        context,
        namespace,
        deployment_name,
        pod_name,
        deployment_config,
    ):
        self.context = context
        self.namespace = namespace
        self.deployment_name = deployment_name
        self.pod_name = pod_name
        self.deployment_config = deployment_config
        cs = deployment_config["spec"]["template"]["spec"]["containers"]
        containers = [c for c in cs if "telepresence-k8s" in c["image"]]
        if not containers:
            raise RuntimeError(
                "Could not find container with image "
                "'datawire/telepresence-k8s' in pod {}.".format(pod_name)
            )
        self.container_config = containers[0]
        self.container_name = self.container_config["name"]

    def remote_telepresence_version(self):
        """Return the version used by the remote Telepresence container."""
        return self.container_config["image"].split(":")[-1]


def _get_service_names(environment):
    """Return names of Services, as used in env variable names."""
    # Order matters for service_keys, need it to be consistent with port
    # forwarding order in remote container.
    result = [
        key[:-len("_SERVICE_HOST")] for key in environment
        if key.endswith("_SERVICE_HOST")
    ]
    result.sort()
    return result


def _get_remote_env(runner, context, namespace, pod_name, container_name):
    """Get the environment variables in the remote pod."""
    env = runner.get_kubectl(
        context, namespace,
        ["exec", pod_name, "--container", container_name, "env"]
    )
    result = {}
    for line in env.splitlines():
        key, value = line.split("=", 1)
        result[key] = value
    return result


def get_deployment_set_keys(remote_info):
    """Get the set of environment variables names set by the Deployment."""
    return set([
        var["name"] for var in remote_info.container_config.get("env", [])
    ])


def get_env_variables(runner, remote_info, context):
    """
    Generate environment variables that match kubernetes.
    """
    # Get the environment:
    remote_env = _get_remote_env(
        runner, context, remote_info.namespace, remote_info.pod_name,
        remote_info.container_name
    )
    service_names = _get_service_names(remote_env)
    deployment_set_keys = get_deployment_set_keys(remote_info)
    # Tell local process about the remote setup, useful for testing and
    # debugging:
    socks_result = {
        "TELEPRESENCE_POD": remote_info.pod_name,
        "TELEPRESENCE_CONTAINER": remote_info.container_name
    }
    # ips proxied via socks, can copy addresses unmodified:
    for key, value in remote_env.items():
        if key in deployment_set_keys:
            # Copy over Deployment-set env variables:
            socks_result[key] = value
        for service_name in service_names:
            # Copy over Service env variables to SOCKS variant:
            if key.startswith(service_name + "_") and (
                key.endswith("_ADDR") or key.endswith("_PORT") or
                key.endswith("_PROTO") or key.endswith("_HOST") or
                key.endswith("_TCP")
            ):
                socks_result[key] = value
    return socks_result


def get_deployment_json(
    runner, deployment_name, context, namespace, deployment_type, run_id=None
):
    """Get the decoded JSON for a deployment.

    If this is a Deployment we created, the run_id is also passed in - this is
    the uuid we set for the telepresence label. Otherwise run_id is None and
    the Deployment name must be used to locate the Deployment.
    """
    assert context is not None
    assert namespace is not None
    try:
        get_deployment = [
            "get",
            deployment_type,
            "-o",
            "json",
            "--export",
        ]
        if run_id is None:
            return json.loads(
                runner.get_kubectl(
                    context,
                    namespace,
                    get_deployment + [deployment_name],
                    stderr=STDOUT
                )
            )
        else:
            # When using a selector we get a list of objects, not just one:
            return json.loads(
                runner.get_kubectl(
                    context,
                    namespace,
                    get_deployment + ["--selector=telepresence=" + run_id],
                    stderr=STDOUT
                )
            )["items"][0]
    except CalledProcessError as e:
        raise SystemExit(
            "Failed to find Deployment '{}': {}".
            format(deployment_name, str(e.stdout, "utf-8"))
        )


def get_remote_info(
    runner, deployment_name, context, namespace, deployment_type, run_id=None
):
    """
    Given the deployment name, return a RemoteInfo object.

    If this is a Deployment we created, the run_id is also passed in - this is
    the uuid we set for the telepresence label. Otherwise run_id is None and
    the Deployment name must be used to locate the Deployment.
    """
    deployment = get_deployment_json(
        runner,
        deployment_name,
        context,
        namespace,
        deployment_type,
        run_id=run_id
    )
    expected_metadata = deployment["spec"]["template"]["metadata"]
    runner.write("Expected metadata for pods: {}\n".format(expected_metadata))

    start = time()
    while time() - start < 120:
        pods = json.loads(
            runner.get_kubectl(
                context, namespace, ["get", "pod", "-o", "json", "--export"]
            )
        )["items"]
        for pod in pods:
            name = pod["metadata"]["name"]
            phase = pod["status"]["phase"]
            runner.write(
                "Checking {} (phase {})...\n".
                format(pod["metadata"].get("labels"), phase)
            )
            if not set(expected_metadata.get("labels", {}).items(
            )).issubset(set(pod["metadata"].get("labels", {}).items())):
                runner.write("Labels don't match.\n")
                continue
            # Metadata for Deployment will hopefully have a namespace. If not,
            # fall back to one we were given. If we weren't given one, best we
            # can do is choose "default".
            if (name.startswith(deployment_name + "-")
                and
                pod["metadata"]["namespace"] == deployment["metadata"].get(
                    "namespace", namespace)
                and
                phase in (
                    "Pending", "Running"
            )):
                runner.write("Looks like we've found our pod!\n")
                remote_info = RemoteInfo(
                    runner,
                    context,
                    namespace,
                    deployment_name,
                    name,
                    deployment,
                )
                # Ensure remote container is running same version as we are:
                if remote_info.remote_telepresence_version() != __version__:
                    raise SystemExit((
                        "The remote datawire/telepresence-k8s container is " +
                        "running version {}, but this tool is version {}. " +
                        "Please make sure both are running the same version."
                    ).format(
                        remote_info.remote_telepresence_version(), __version__
                    ))
                # Wait for pod to be running:
                wait_for_pod(runner, remote_info)
                return remote_info

        # Didn't find pod...
        sleep(1)

    raise RuntimeError(
        "Telepresence pod not found for Deployment '{}'.".
        format(deployment_name)
    )


def ssh(runner, ssh_port, args):
    """Connect to remote pod via SSH.

    Returns Popen object.
    """
    return runner.popen([
        "ssh",
        # Ignore local configuration (~/.ssh/config)
        "-F",
        "/dev/null",
        # SSH with no warnings:
        "-vv" if runner.verbose else "-q",
        # Don't validate host key:
        "-oStrictHostKeyChecking=no",
        # Don't store host key:
        "-oUserKnownHostsFile=/dev/null",
        # Ping once a second; after ten retries will disconnect:
        "-oServerAliveInterval=1",
        "-oServerAliveCountMax=10",
        # No shell:
        "-N",
        "-p",
        str(ssh_port),
        "telepresence@localhost",
    ] + args)


def wait_for_ssh(runner, ssh_port):
    start = time()
    while time() - start < 30:
        try:
            runner.check_call([
                "ssh", "-F", "/dev/null", "-p", str(ssh_port), "-vv"
                if runner.verbose else "-q", "-oUserKnownHostsFile=/dev/null",
                "-oStrictHostKeyChecking=no", "telepresence@localhost",
                "/bin/true"
            ])
        except CalledProcessError:
            sleep(0.25)
        else:
            return
    raise RuntimeError("SSH isn't starting.")


def wait_for_pod(runner, remote_info):
    start = time()
    while time() - start < 120:
        try:
            pod = json.loads(
                runner.get_kubectl(
                    remote_info.context, remote_info.namespace,
                    ["get", "pod", remote_info.pod_name, "-o", "json"]
                )
            )
        except CalledProcessError:
            sleep(0.25)
            continue
        if pod["status"]["phase"] == "Running":
            for container in pod["status"]["containerStatuses"]:
                if container["name"] == remote_info.container_name and (
                    container["ready"]
                ):
                    return
        sleep(0.25)
    raise RuntimeError(
        "Pod isn't starting or can't be found: {}".format(pod["status"])
    )


def connect(runner, remote_info, local_exposed_ports, context, verbose):
    """
    Start all the processes that handle remote proxying.

    Return list of Popen instances.
    """
    processes = []
    ssh_port = find_free_port()

    # forward remote port to here, by tunneling via remote SSH server:
    processes.append(
        runner.popen(
            runner.kubectl(
                context, remote_info.namespace, [
                    "port-forward", remote_info.pod_name,
                    "{}:8022".format(ssh_port)
                ]
            )
        )
    )
    atexit.register(killall, processes)
    wait_for_ssh(runner, ssh_port)

    for port_number in local_exposed_ports:
        processes.append(
            ssh(
                runner, ssh_port,
                ["-R", "*:{}:127.0.0.1:{}".format(port_number, port_number)]
            )
        )

    # start tunnel to remote SOCKS proxy, for telepresence --run.
    socks_port = find_free_port()
    processes.append(
        ssh(
            runner, ssh_port,
            ["-L", "127.0.0.1:{}:127.0.0.1:9050".format(socks_port)]
        ),
    )

    return processes, socks_port, ssh_port


def killall(processes):
    for p in processes:
        if p.poll() is None:
            p.terminate()
    for p in processes:
        try:
            p.wait(timeout=1)
        except TimeoutExpired:
            p.kill()
            p.wait()


def create_new_deployment(runner, args):
    """Create a new Deployment, return its name."""
    run_id = str(uuid4())

    def remove_existing_deployment():
        runner.get_kubectl(
            args.context, args.namespace, [
                "delete",
                "--ignore-not-found",
                "all",
                "--selector=telepresence=" + run_id,
            ]
        )

    atexit.register(remove_existing_deployment)
    remove_existing_deployment()
    command = [
        "run",
        # This will result in using Deployment:
        "--restart=Always",
        "--limits=memory=256Mi",
        "--requests=memory=64Mi",
        args.new_deployment,
        "--image=" + TELEPRESENCE_IMAGE,
        "--labels=telepresence=" + run_id,
    ]
    for port in args.expose:
        command.append("--port={}".format(port))
    if args.expose:
        command.append("--expose")
    runner.get_kubectl(args.context, args.namespace, command)
    return args.new_deployment, run_id


def swap_deployment(runner, args):
    """Swap out an existing Deployment, return its name.

    Native Kubernetes version.
    """
    run_id = str(uuid4())

    deployment_name, *container_name = args.swap_deployment.split(":", 1)
    if container_name:
        container_name = container_name[0]
    deployment_json = get_deployment_json(
        runner,
        deployment_name,
        args.context,
        args.namespace,
        "deployment",
    )

    def apply_json(json_config):
        runner.check_kubectl(
            args.context,
            args.namespace, ["apply", "-f", "-"],
            input=json.dumps(json_config).encode("utf-8")
        )

    atexit.register(apply_json, deployment_json)

    # If no container name was given, just use the first one:
    if not container_name:
        container_name = deployment_json["spec"]["template"]["spec"][
            "containers"
        ][0]["name"]

    new_deployment_json = deepcopy(deployment_json)
    new_deployment_json["spec"]["replicas"] = 1
    new_deployment_json["metadata"].setdefault("labels",
                                               {})["telepresence"] = run_id
    new_deployment_json["spec"]["template"]["metadata"].setdefault(
        "labels", {}
    )["telepresence"] = run_id
    for container in new_deployment_json["spec"]["template"]["spec"
                                                             ]["containers"]:
        if container["name"] == container_name:
            container["image"] = TELEPRESENCE_IMAGE
            # Not strictly necessary for real use, but tests break without this
            # since we don't upload test images to Docker Hub:
            container["imagePullPolicy"] = "IfNotPresent"
            apply_json(new_deployment_json)
            return deployment_name, run_id

    raise RuntimeError(
        "Couldn't find container {} in deployment {}.".
        format(deployment_name, container_name)
    )


def swap_deployment_openshift(runner, args):
    """
    Swap out an existing DeploymentConfig, return its name.

    In practice OpenShift doesn't seem to do the right thing when a
    DeploymentConfig is updated. In particular, we need to disable the image
    trigger so that we can use the new image, but the replicationcontroller
    then continues to deploy the existing image.

    So instead we use a different approach than for Kubernetes, replacing the
    current ReplicationController with one that uses the Telepresence image,
    then restores it. We delete the pods to force the RC to do its thing.
    """
    run_id = str(uuid4())
    deployment_name, *container_name = args.swap_deployment.split(":", 1)
    if container_name:
        container_name = container_name[0]
    rcs = runner.get_kubectl(
        args.context, args.namespace, [
            "get", "rc", "-o", "name", "--selector",
            "openshift.io/deployment-config.name=" + deployment_name
        ]
    )
    rc_name = sorted(
        rcs.split(), key=lambda n: int(n.split("-")[-1])
    )[0].split("/", 1)[1]
    rc_json = json.loads(
        runner.get_kubectl(
            args.context,
            args.namespace, ["get", "rc", "-o", "json", "--export", rc_name],
            stderr=STDOUT
        )
    )

    def apply_json(json_config):
        runner.check_kubectl(
            args.context,
            args.namespace, ["apply", "-f", "-"],
            input=json.dumps(json_config).encode("utf-8")
        )
        # Now that we've updated the replication controller, delete pods to
        # make sure changes get applied:
        runner.check_kubectl(
            args.context, args.namespace,
            ["delete", "pod", "--selector", "deployment=" + rc_name]
        )

    atexit.register(apply_json, rc_json)

    # If no container name was given, just use the first one:
    if not container_name:
        container_name = rc_json["spec"]["template"]["spec"]["containers"
                                                             ][0]["name"]

    new_rc_json = deepcopy(rc_json)
    new_rc_json["spec"]["replicas"] = 1
    new_rc_json["metadata"].setdefault("labels", {})["telepresence"] = run_id
    new_rc_json["spec"]["template"]["metadata"].setdefault(
        "labels", {}
    )["telepresence"] = run_id
    for container in new_rc_json["spec"]["template"]["spec"]["containers"]:
        if container["name"] == container_name:
            container["image"] = TELEPRESENCE_IMAGE
            # Not strictly necessary for real use, but tests break without this
            # since we don't upload test images to Docker Hub:
            container["imagePullPolicy"] = "IfNotPresent"
            apply_json(new_rc_json)
            return deployment_name, run_id

    raise RuntimeError(
        "Couldn't find container {} in deployment {}.".
        format(deployment_name, container_name)
    )


def start_proxy(runner, args):
    """Start the kubectl port-forward and SSH clients that do the proxying."""
    if sys.stderr.isatty():
        print("Starting proxy...", file=sys.stderr)

    run_id = None

    if args.new_deployment is not None:
        # This implies --new-deployment:
        args.deployment, run_id = create_new_deployment(runner, args)

    if args.swap_deployment is not None:
        # This implies --swap-deployment
        if runner.kubectl_cmd == "oc":
            args.deployment, run_id = swap_deployment_openshift(runner, args)
        else:
            args.deployment, run_id = swap_deployment(runner, args)

    deployment_type = "deployment"
    if runner.kubectl_cmd == "oc":
        # OpenShift Origin uses DeploymentConfig instead, but for swapping we
        # mess with RweplicationController instead because mutating DC doesn't
        # work:
        if args.swap_deployment:
            deployment_type = "rc"
        else:
            deployment_type = "deploymentconfig"

    remote_info = get_remote_info(
        runner,
        args.deployment,
        args.context,
        args.namespace,
        deployment_type,
        run_id=run_id,
    )

    processes, socks_port, ssh_port = connect(
        runner,
        remote_info,
        args.expose,
        args.context,
        args.verbose
    )

    # Get the environment variables we want to copy from the remote pod; it may
    # take a few seconds for the SSH proxies to get going:
    start = time()
    while time() - start < 5:
        try:
            env = get_env_variables(runner, remote_info, args.context)
            break
        except CalledProcessError:
            sleep(0.25)

    return processes, env, socks_port, ssh_port, remote_info


TORSOCKS_CONFIG = """
# Allow process to listen on ports:
AllowInbound 1
# Allow process to connect to localhost:
AllowOutboundLocalhost 1
# Connect to custom port for SOCKS server:
TorPort {}
"""


def sip_workaround():
    """
    Workaround System Integrity Protection.

    Newer OS X don't allow injecting libraries into binaries in /bin, /sbin and
    /usr. We therefore make a copy of them and modify $PATH to point at their
    new location. It's only ~100MB so this should be pretty fast!
    """
    protected = {"/bin", "/sbin", "/usr/sbin", "/usr/bin"}
    # Remove protected paths from $PATH:
    paths = [p for p in os.environ["PATH"].split(":") if p not in protected]
    # Add temp dir
    bin_dir = mkdtemp()
    paths.insert(0, bin_dir)
    atexit.register(rmtree, bin_dir)
    for directory in protected:
        for file in os.listdir(directory):
            try:
                copy(os.path.join(directory, file), bin_dir)
            except IOError:
                continue
            os.chmod(os.path.join(bin_dir, file), 0o775)
    # Return new $PATH
    return ":".join(paths)


def wait_for_exit(runner, shell_process, processes):
    """Given Popens, wait for one of them to die."""
    while True:
        sleep(0.1)
        if shell_process.poll() is not None:
            # Shell exited, we're done. Automatic shutdown cleanup will kill
            # subprocesses.
            raise SystemExit(shell_process.poll())
        for p in processes:
            code = p.poll()
            if code is not None:
                runner.write((
                    "A subprocess ({}) died with code {}, " +
                    "killing all processes...\n"
                ).format(p.args, p.returncode))
                killall(processes)
                # Unfortunatly torsocks doesn't deal well with connections
                # being lost, so best we can do is shut down.
                if sys.stderr.isatty:
                    print(
                        "Proxy to Kubernetes exited. This is typically due to"
                        " a lost connection.",
                        file=sys.stderr
                    )
                raise SystemExit(3)


def mount_remote_volumes(runner, remote_info, ssh_port):
    """
    sshfs is used to mount the remote system locally.
    """
    mount_dir = mkdtemp()
    try:
        runner.check_call([
            "sshfs",
            "-p",
            str(ssh_port),
            # Don't load config file so it doesn't break us:
            "-F",
            "/dev/null",
            # Don't validate host key:
            "-o",
            "StrictHostKeyChecking=no",
            # Don't store host key:
            "-o",
            "UserKnownHostsFile=/dev/null",
            "telepresence@localhost:/",
            mount_dir
        ])
        mounted = True
    except CalledProcessError:
        print(
            "Mounting remote volumes failed, they will be unavailable"
            " in this session. This is a known issue when running inside"
            " Docker or on Windows Subystem for Linux. See"
            " https://github.com/datawire/telepresence/issues/115 for"
            " details. If you are not running in those environments"
            " then please report a bug, attaching telepresence.log to"
            " the bug report.",
            file=sys.stderr
        )
        mounted = False
    return mount_dir, mounted


NICE_FAILURE = """\
#!/bin/sh
echo {} is not supported under Telepresence.
echo See \
http://www.telepresence.io/user-guide/limitations-and-workarounds.html \
for details.
exit 55
"""


def get_unsupported_tools(dns_supported):
    """
    Create replacement command-line tools that just error out, in a nice way.
    """
    commands = ["ping", "traceroute"]
    if not dns_supported:
        commands += ["nslookup", "dig", "host"]
    unsupported_bin = mkdtemp()
    for command in commands:
        path = unsupported_bin + "/" + command
        with open(path, "w") as f:
            f.write(NICE_FAILURE.format(command))
        os.chmod(path, 0o755)
    return unsupported_bin


# Script to dump resolved IPs to stdout as JSON list:
_GET_IPS_PY = """
import socket, sys, json

result = []
for host in sys.argv[1:]:
    result.append(socket.gethostbyname(host))
sys.stdout.write(json.dumps(result))
sys.stdout.flush()
"""


def get_proxy_cidrs(runner, args, remote_info, service_address):
    """
    Figure out which IP ranges to route via sshuttle.

    1. Given the IP address of a service, figure out IP ranges used by
       Kubernetes services. We assume /16 for the services.
    2. Extract pod ranges from API.
    3. Any hostnames/IPs given by the user using --also-proxy.

    See https://github.com/kubernetes/kubernetes/issues/25533 for eventual
    long-term solution for service CIDR.
    """

    # Run script to convert --also-proxy hostnames to IPs, doing name
    # resolution inside Kubernetes, so we get cloud-local IP addresses for
    # cloud resources:
    def resolve_ips():
        return json.loads(
            runner.get_kubectl(
                args.context, args.namespace, [
                    "exec", "--container=" + remote_info.container_name,
                    remote_info.pod_name, "--", "python3", "-c", _GET_IPS_PY
                ] + args.also_proxy
            )
        )

    try:
        result = set([ip + "/32" for ip in resolve_ips()])
    except CalledProcessError as e:
        runner.write(str(e))
        raise SystemExit(
            "We failed to do a DNS lookup inside Kubernetes for the "
            "hostname(s) you listed in "
            "--also-proxy ({}). Maybe you mistyped one of them?".
            format(", ".join(args.also_proxy))
        )

    def ip_to_16range(ip):
        # Convert IP address, e.g. '10.3.241.23', into 16 bit range, in this
        # case '10.3.0.0/16':
        return ".".join(ip.split(".")[:2] + ["0", "0"]) + "/16"

    # Get pod IPs from nodes if possible, otherwise use pod IPs as heuristic:
    try:
        nodes = json.loads(
            runner.
            get_output([runner.kubectl_cmd, "get", "nodes", "-o", "json"])
        )["items"]
    except CalledProcessError as e:
        runner.write("Failed to get nodes: {}".format(e))
        # Fallback to using pod IPs:
        pods = json.loads(
            runner.
            get_output([runner.kubectl_cmd, "get", "pods", "-o", "json"])
        )["items"]
        for pod in pods:
            result.add(ip_to_16range(pod["status"]["podIP"]))
    else:
        for node in nodes:
            pod_cidr = node["spec"].get("podCIDR")
            if pod_cidr is not None:
                result.add(pod_cidr)

    # Add service IP range, based on heuristic:
    result.add(ip_to_16range(service_address))

    return list(result)


def run_local_command(
    runner, remote_info, args, env_overrides, subprocesses, socks_port,
    ssh_port
):
    """--run-shell/--run support, run command locally."""
    env = os.environ.copy()
    env.update(env_overrides)
    # Create custom torsocks.conf, since some options we want (in particular,
    # port) aren't accessible via env variables in older versions of torconf:
    with NamedTemporaryFile(mode="w+", delete=False) as tor_conffile:
        tor_conffile.write(TORSOCKS_CONFIG.format(socks_port))
    env["TORSOCKS_CONF_FILE"] = tor_conffile.name
    if runner.logfile is not sys.stdout:
        env["TORSOCKS_LOG_FILE_PATH"] = runner.logfile.name
    # Don't use runner.popen() since we want to give program access to current
    # stdout and stderr if it wants it.
    env["PROMPT_COMMAND"] = (
        'PS1="@{}|$PS1";unset PROMPT_COMMAND'.format(args.context)
    )
    if sys.platform == "darwin" and args.method == "inject-tcp":
        env["PATH"] = sip_workaround()
    # Inject replacements for unsupported tools like ping:
    env["PATH"] = get_unsupported_tools(args.method != "inject-tcp"
                                        ) + ":" + env["PATH"]
    mount_dir, mounted = mount_remote_volumes(runner, remote_info, ssh_port)
    env["TELEPRESENCE_ROOT"] = mount_dir
    # Make sure we use "bash", no "/bin/bash", so we get the copied version on
    # OS X:
    if args.run is None:
        # We skip .bashrc since it might e.g. have kubectl running to get bash
        # autocomplete, and Go programs don't like DYLD on macOS at least (see
        # https://github.com/datawire/telepresence/issues/125).
        command = ["bash", "--norc"]
    else:
        command = args.run
    if args.method == "inject-tcp":
        p = Popen(["torsocks"] + command, env=env)
    elif args.method == "vpn-tcp":
        # Make sure we have sudo credentials by doing a small sudo in advance
        # of sshuttle using it:
        Popen(["sudo", "true"]).wait()
        sshuttle_method = "auto"
        if sys.platform.startswith("linux"):
            # sshuttle tproxy mode seems to have issues:
            sshuttle_method = "nat"
        subprocesses.append(
            runner.popen([
                "sshuttle-telepresence",
                "-v",
                "--dns",
                "--method",
                sshuttle_method,
                "-e",
                (
                    "ssh -oStrictHostKeyChecking=no " +
                    "-oUserKnownHostsFile=/dev/null"
                ),
                "-r",
                "telepresence@localhost:" + str(ssh_port),
            ] + get_proxy_cidrs(
                runner, args, remote_info, env["KUBERNETES_SERVICE_HOST"]
            ))
        )

        # sshuttle will take a while to startup. We can detect it being up when
        # DNS resolution of services starts working. We use a specific single
        # segment so any search/domain statements in resolv.conf are applied,
        # which then allows the DNS proxy to detect the suffix domain and
        # filter it out.
        def get_hellotelepresence():
            # On Macs I suspect there is per-process caching and OS-level
            # caching, so clear out caches by having separate process calls and
            # explicit caching:
            try:
                runner.get_output([
                    "python3", "-c",
                    "import socket; socket.gethostbyname('hellotelepresence')"
                ])
            except CalledProcessError:
                if sys.platform == "darwin":
                    runner.get_output([
                        "sudo", "killall", "-HUP", "mDNSResponder"
                    ])
                raise

        start = time()
        while time() - start < 10:
            try:
                get_hellotelepresence()
                sleep(1)  # just in case there's more to startup
                break
            except CalledProcessError:
                sleep(0.1)
            else:
                sleep(0.1)
        get_hellotelepresence()
        p = Popen(command, env=env)
    else:
        raise RuntimeError(
            "GADZOOKS, A BUG: unknown proxying method " + args.method
        )

    def terminate_if_alive():
        runner.write("Shutting down local shell...\n")
        os.remove(tor_conffile.name)
        if p.poll() is None:
            runner.write("Killing local shell...\n")
            p.terminate()
            p.wait()
        if mounted:
            if sys.platform.startswith("linux"):
                runner.get_output(["fusermount", "-u", mount_dir])
            else:
                runner.get_output(["umount", mount_dir])
        os.rmdir(mount_dir)

    atexit.register(terminate_if_alive)
    wait_for_exit(runner, p, subprocesses)


BUG_REPORT_TEMPLATE = u"""\
### What were you trying to do?

(please tell us)

### What did you expect to happen?

(please tell us)

### What happened instead?

(please tell us - the traceback is automatically included, see below)

### Automatically included information

Command line: `{}`
Version: `{}`
Python version: `{}`
kubectl version: `{}`
oc version: `{}`
OS: `{}`
Traceback:

```
{}
```

Logs:

```
{}
```
"""


def require_command(runner, command, message=None):
    if message is None:
        message = "Please install " + command
    try:
        runner.get_output(["which", command])
    except CalledProcessError as e:
        sys.stderr.write(message + "\n")
        sys.stderr.write(
            "See the documentation at http://telepresence.io "
            "for more details.\n"
        )
        raise SystemExit(1)


FEEDBACK_QUESTION = """
Would you like to file an issue with your question?
We'll try to answer quickly, and then improve the documentation going forward.
File an issue: [N/y] """


def ask_for_feedback():
    """Ask user if they want to provide feedback."""
    if input(FEEDBACK_QUESTION) == "y":
        webbrowser.open_new(
            "https://github.com/datawire/telepresence/issues/new"
        )


def kubectl_or_oc(server):
    """Figure out if we're using "kubectl" or "oc"."""
    if which("oc") is None:
        return "kubectl"
    # We've got oc, and possibly kubectl as well. We only want oc for OpenShift
    # servers, so check for an OpenShift API endpoint:
    ctx = ssl.create_default_context()
    ctx.check_hostname = False
    ctx.verify_mode = ssl.CERT_NONE
    try:
        with urlopen(server + "/version/openshift", context=ctx) as u:
            u.read()
    except HTTPError:
        return "kubectl"
    else:
        return "oc"


def main():
    # Make SIGTERM do clean shutdown (in particular, we want atexit functions
    # to be called):
    def shutdown(signum, frame):
        raise SystemExit(0)

    signal.signal(signal.SIGTERM, shutdown)

    try:
        args = parse_args()
    except SystemExit as e:
        if sys.stdin.isatty() and e.code == 0 and sys.argv[1:] in (["-h"],
                                                                   ["--help"]):
            ask_for_feedback()
        raise

    @handle_unexpected_errors(args.logfile)
    def go():
        # We don't quite know yet if we want kubectl or oc (if someone has both
        # it depends on the context), so until we know the context just guess.
        # We prefer kubectl over oc insofar as (1) kubectl commands we do in
        # this prelim setup stage don't require oc and (2) sometimes oc is a
        # different binary unrelated to OpenShift.
        if which("kubectl"):
            prelim_command = "kubectl"
        elif which("oc"):
            prelim_command = "oc"
        else:
            raise SystemExit("Found neither 'kubectl' nor 'oc' in your $PATH.")

        # Make sure we have a Kubernetes context set either on command line or
        # in kubeconfig:
        if args.context is None:
            try:
                args.context = str(
                    check_output([prelim_command, "config", "current-context"],
                                 stderr=STDOUT), "utf-8"
                ).strip()
            except CalledProcessError:
                raise SystemExit(
                    "No current-context set. "
                    "Please use the --context option to explicitly set the "
                    "context."
                )

        # Figure out explicit namespace if its not specified, and the server
        # address (we use the server address to determine for good whether we
        # want oc or kubectl):
        kubectl_config = json.loads(
            str(
                check_output([prelim_command, "config", "view", "-o", "json"]),
                "utf-8"
            )
        )
        for context_setting in kubectl_config["contexts"]:
            if context_setting["name"] == args.context:
                if args.namespace is None:
                    args.namespace = context_setting["context"].get(
                        "namespace", "default"
                    )
                cluster = context_setting["context"]["cluster"]
                break
        for cluster_setting in kubectl_config["clusters"]:
            if cluster_setting["name"] == cluster:
                server = cluster_setting["cluster"]["server"]

        # Log file path should be absolute since some processes may run in
        # different directories:
        if args.logfile != "-":
            args.logfile = os.path.abspath(args.logfile)
        runner = Runner.open(args.logfile, kubectl_or_oc(server), args.verbose)
        runner.write(
            "Context: {}, namespace: {}, kubectl_command: {}\n".
            format(args.context, args.namespace, runner.kubectl_cmd)
        )

        # minikube breaks DNS because DNS gets captured, sent to minikube,
        # which sends it back to DNS server set by host, resulting in loop...
        if args.context == "minikube" and args.method == "vpn-tcp":
            raise SystemExit(
                "vpn-tcp method doesn't work with minikube.\n"
                "See https://github.com/datawire/telepresence/issues/160"
            )

        # Make sure we can access Kubernetes:
        try:
            if runner.kubectl_cmd == "oc":
                status_command = "status"
            else:
                status_command = "cluster-info"
            runner.get_output([
                runner.kubectl_cmd, "--context", args.context, status_command
            ])
        except (CalledProcessError, OSError, IOError) as e:
            sys.stderr.write("Error accessing Kubernetes: {}\n".format(e))
            raise SystemExit(1)
        # Make sure we can run openssh:
        try:
            runner.write("Running 'ssh -V'...")
            version = check_output(["ssh", "-V"], stdin=DEVNULL, stderr=STDOUT)
            if not version.startswith(b"OpenSSH"):
                raise SystemExit(
                    "'ssh' is not the OpenSSH client, apparently."
                )
        except (CalledProcessError, OSError, IOError) as e:
            sys.stderr.write("Error running ssh: {}\n".format(e))
            raise SystemExit(1)
        # Other requirements:
        require_command(
            runner, "torsocks", "Please install torsocks (v2.1 or later)"
        )
        require_command(runner, "sshfs")
        subprocesses, env, socks_port, ssh_port, remote_info = start_proxy(
            runner, args
        )
        run_local_command(
            runner, remote_info, args, env, subprocesses, socks_port, ssh_port
        )

    go()


if __name__ == '__main__':
    main()
